Spring Cloud & Security

프로젝트를 진행하며 스프링 게이트웨이에 인증 역할을 맡겼는데 이때 검색해본 MSA Spring Boot에서 인증(Authentication)과 인가(Authorization)의 전체 라이프사이클을 설명하며, API 게이트웨이와 각 마이크로서비스의 구체적인 역할을 알아보겠습니다.

구성 요소와 역할

구성 요소 주요 책임 비유
사용자 / 클라이언트 로그인 후 JWT를 안전하게 저장하고 요청을 시작함 건물에 들어가려는 사람
API 게이트웨이 경계 보안 및 인증
모든 요청 및 JWT 검증의 최전선
건물 정문 경비원
사용자 서비스 신원 제공자
사용자 데이터 관리, 비밀번호 검증 및 로그인 성공 시 JWT 발급
출입증 발급 사무소
다른 마이크로서비스 세부 권한 부여
게이트웨이의 인증을 신뢰하고 해당 사용자 신원으로 비즈니스 논리 처리
특정 사무실 출입에 필요한 권한 카드 담당자

플로우 1: 로그인 프로세스 (토큰 획득)

사용자가 본인의 신원을 증명하고 "보안 출입증"(JWT)을 발급 받는 과정입니다.

  1. 로그인 요청: 사용자가 아이디와 비밀번호를 POST /users/login 등 API 게이트웨이의 공개 엔드포인트로 전송합니다.
  2. 라우팅: 게이트웨이는 /users/** 경로임을 인지하고, 해당 요청을 사용자 서비스로 전달합니다. 이때 토큰은 필요 없습니다.
  3. 비밀번호 검증: 사용자 서비스는 PasswordEncoder를 사용해 입력 비밀번호를 해시하고, DB의 저장된 해시와 비교합니다.
  4. 토큰 생성: 비밀번호가 일치하면 JwtUtil.createToken()을 호출해 JWT를 생성합니다. 토큰에는 다음과 같은 클레임이 포함됩니다:
    • sub (주체): 사용자 고유 ID (예: 123)
    • roles: 사용자 역할 목록 (예: ["ADMIN", "CUSTOMER"])
    • nickname: 사용자 닉네임 (예: "Alice")
    • exp (만료시간): 토큰 만료 시점. 서명은 jwt.secret 키로 이루어집니다.
  5. 토큰 응답: 사용자 서비스는 서명된 JWT 문자열을 사용자에게 반환합니다.
  6. 토큰 저장: 사용자의 브라우저 또는 앱에서 JWT를 안전하게 저장합니다 (예: localStorage나 HTTP-only 쿠키).

플로우 2: 인증된 요청 (토큰 사용)

토큰을 받은 사용자가 보호된 리소스에 접근할 때 흐름입니다.

A. API 게이트웨이 (인증)

  1. 토큰 포함 요청: 사용자가 Authorization: Bearer <토큰> 헤더를 포함해 보호된 엔드포인트(예: GET /some-other-service/data)에 요청합니다.
  2. Spring Security 인터셉션: 게이트웨이의 WebSecurityConfig에서 oauth2ResourceServer 필터가 자동 실행되어 요청을 가로챕니다.
  3. 토큰 검증: 필터는 SecurityConfig.java에 정의된 ReactiveJwtDecoder로 JWT 서명을 검증하고 만료 여부를 확인합니다. 실패하면 401 Unauthorized를 반환합니다.
  4. Principal 생성: 검증 성공 시 Spring Security가 JWT 클레임을 담은 JwtAuthenticationToken 객체(Principal)를 생성합니다.
  5. 헤더 추가: 사용자가 만든 UserInfoFilter(글로벌 필터)가 Principal에서 사용자 정보를 꺼내 다음과 같은 신뢰된 헤더로 요청에 추가합니다:
    • X-User-Id
    • X-User-Roles
    • X-User-Nickname
  6. 라우팅: 이 인증 정보를 담은 요청은 내부 마이크로서비스로 전달됩니다.

B. 마이크로서비스 측 (인가)

  1. 요청 도착: user-service 등 각 마이크로서비스는 X-User-* 헤더가 포함된 요청을 받습니다.
  2. 커스텀 필터 실행: JwtAuthenticationFilter (한 번만 실행되는 필터)가 이 헤더들을 읽습니다.
  3. 보안 컨텍스트 설정: 이 필터는 UsernamePasswordAuthenticationToken 객체를 만들어 SecurityContextHolder에 설정합니다. 이 컨텍스트는 요청 내내 현재 사용자의 신원 정보를 보관합니다.
  4. 인가 검사: 컨트롤러 메서드에 @PreAuthorize("hasRole('ADMIN')") 같은 권한 검사 어노테이션이 있으면, SecurityContextHolder의 인증 정보를 기반으로 검사하여 권한이 없으면 403 Forbidden 반환.
  5. 컨트롤러 로직: 컨트롤러 내에서는 principal.getName() 등으로 현재 사용자를 확인하고, 해당 사용자에 맞는 비즈니스 로직을 수행합니다.

이 전체 과정은 강력하고 확장성이 뛰어난 "제로 트러스트(Zero Trust)" 아키텍처를 구현합니다.
게이트웨이에서 인증을 집중 처리하고, 각 마이크로서비스에서는 상세한 권한 체크를 담당합니다.

Spring Security 필터 체인 심층 분석

Spring Security는 표준 자바 서블릿 필터(Filter) 패턴 위에서 작동하며, 애플리케이션 디스패처 전 요청/응답 사이클에 개입하여 보안을 관리합니다.

주요 컴포넌트와 과정

  1. 서블릿 컨테이너 (예: Tomcat)

    • HTTP 요청을 수신하고 HttpServletRequest, HttpServletResponse 객체 생성.
    • 정의된 FilterChain의 첫 필터에 요청 전달.
  2. DelegatingFilterProxy (Spring과 서블릿 사이 브릿지)

    • 서블릿 컨테이너는 Spring 컨텍스트를 알지 못하므로, 이 프록시가 Spring ApplicationContext 내 실제 보안 필터 빈(springSecurityFilterChain)으로 위임.
  3. FilterChainProxy (실제 보안 필터 체인 관리자)

    • 여러 보안 필터를 순서대로 실행하며, 요청 URL에 맞는 첫 번째 SecurityFilterChain 선택.
  4. SecurityFilterChain 내 핵심 필터 예시

    • CsrfFilter: CSRF 공격 방어 (Stateless JWT 환경에서는 보통 비활성화)
    • HeaderWriterFilter: 보안 헤더 추가
    • UsernamePasswordAuthenticationFilter: 폼 로그인 처리 (JWT 인증에는 미사용)
    • BearerTokenAuthenticationFilter / OAuth2ResourceServerFilter: JWT 인증을 담당하는 주요 필터 (게이트웨이에서 동작)
    • 사용자 정의 JwtAuthenticationFilter: trusted 헤더를 읽어 SecurityContext 준비
    • AuthorizationFilter: 최종 권한 검사 실행 (메서드 수준 권한 체크 등)
  5. SecurityContextHolder (사용자 신원 저장소)

    • ThreadLocal을 이용해, 요청이 처리되는 스레드 단위로 현재 사용자 인증 정보를 저장.
    • 요청 처리 완료 시 자동 정리되어 요청 간 정보 누수 방지.

SecurityContextHolder에 Authentication 설정 순서

  1. 인증 필터(JwtAuthenticationFilter)가 사용자 정보 확인.
  2. UsernamePasswordAuthenticationToken 객체 생성 (사용자 ID, 권한 리스트 포함).
  3. 이 객체를 SecurityContextHolder에 저장:
    SecurityContextHolder.getContext().setAuthentication(authentication);
    
  4. 이후 요청 내 다른 컴포넌트가 현재 사용자 신분을 이 컨텍스트에서 확인.

DispatcherServlet 및 컨트롤러

참고: 사용자 ID와 역할 확인 코드 예시

@PostMapping("/some-endpoint")
public ResponseEntity<Result<?>> yourControllerMethod(Authentication authentication) {
    String userIdAsString = authentication.getName();
    Long userId = Long.parseLong(userIdAsString);

    Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

    boolean isAdmin = authorities.stream()
            .anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_ADMIN"));

    if (isAdmin) {
        System.out.println("User is an ADMIN!");
    }

    authorities.forEach(authority -> System.out.println("Role: " + authority.getAuthority()));

    return ResponseEntity.ok(Result.success("..."));
}